feat: packageExtensions for root-owned dependency manifest repairs#9496
Merged
owlstronaut merged 12 commits intoJun 18, 2026
Merged
Conversation
0deca0b to
29a8bf4
Compare
e90d92f to
64989e3
Compare
Contributor
Author
|
Chaotic merge conflict 😆 |
21da3e1 to
c2114a1
Compare
c2114a1 to
c13cf4f
Compare
owlstronaut
approved these changes
Jun 18, 2026
Contributor
Author
|
Codex found one for isolated dependencies 😄 - #9568 |
This was referenced Jun 18, 2026
owlstronaut
pushed a commit
that referenced
this pull request
Jun 18, 2026
…9569) Follow-up of #9496 Under `install-strategy=linked`, a root `packageExtensions` rule that adds a missing dependency installs and works at runtime, but every command that reads the actual tree loses the edge: - `npm ls --all --json` omits the extension-created dependency and its provenance. - `npm explain <dep>` fails with `No dependencies found matching <dep>`. - `npm patch add <dep>` fails with `EPATCHNOTINSTALLED`. Hoisted installs and normally-declared transitive deps are unaffected. ## References Fixes #9568
owlstronaut
pushed a commit
that referenced
this pull request
Jun 18, 2026
…ch (#9570) A workspace `packageExtensions` warning was printed twice per matching workspace. A workspace appears in the inventory as two `isWorkspace` nodes — the Link and its target — and `#warnWorkspacePackageExtensions` warned for both. The fix skips the link (`node.isLink`) so each workspace is warned once via its target node; the selector/`wouldMatch` checks are otherwise unchanged. ## References Follow up of #9496
owlstronaut
pushed a commit
to npm/rfcs
that referenced
this pull request
Jun 18, 2026
Bot-generated transition of RFC **#55** to status `implemented`. Moved to `implemented/0055-package-manifest-extensions.md`. Front-matter `status` and the relevant date field were updated. `INDEX.md` was regenerated. Implementation: npm/cli#9496 Co-authored-by: npm CLI robot <npm-cli+bot@github.com>
owlstronaut
pushed a commit
to npm/rfcs
that referenced
this pull request
Jun 19, 2026
## Summary Adds an RFC for a root-owned `.npm-extension.mjs` / `.npm-extension.cjs` file with a top-level `transformManifest(pkg, context)` extension point. The proposal lets a project imperatively repair third-party package manifests before Arborist reads dependency and peer edges. It builds on the accepted [Package Manifest Extensions proposal](#889) and its [npm CLI implementation](npm/cli#9496), which established the pre-resolution manifest repair phase, root-only authority model, lockfile visibility model, and publish isolation for local dependency metadata repairs. `packageExtensions` remains the safer declarative default for common metadata repairs. `.npm-extension` covers the cases where projects need comments, upstream issue links, repeated transformations, conditional logic, reading existing manifest values, deletion, dependency-range replacement, or a local policy file that does not live in publishable `package.json`. ## Motivation `install-strategy=linked` makes dependency boundaries stricter by avoiding accidental hoisting. That is useful for correctness, but it exposes packages that import dependencies or type packages they did not declare. The accepted `packageExtensions` RFC handles the most common form of this problem: small deterministic additions to dependency and peer metadata. Some repairs are harder to keep clear as declarative JSON. During the [Package Manifest Extensions proposal discussion](#889 (comment)), a Gutenberg migration example repeated the same optional `@types/react` peer repair across many React-related dependencies. The declarative form worked, but the underlying policy was really a named list or predicate: "these packages import React types but do not declare an optional `@types/react` peer." Other local repairs need conditional logic, such as adding a type package only when a matching runtime peer exists, copying an existing peer range into a type dependency, narrowing a known bad peer range, or throwing when upstream has fixed metadata and a local repair should be removed. `packageExtensions` intentionally does not support that kind of code. This RFC proposes an explicit advanced escape hatch for those cases while preserving npm's root-owned authority model, lockfile visibility, and publish isolation. ## Why a separate extension file The RFC intentionally keeps executable policy out of `package.json`. A public package may need local dependency repairs for its own tests, build, or linked-install migration, but it should not publish root-only install policy to the registry manifest or packument. `.npm-extension` is also deliberately not named `.npmfile` or shaped like pnpm's `hooks.readPackage`. The proposal borrows the useful manifest-transform idea from pnpm, but defines npm-specific semantics for trust, lockfile hashing, `npm ci`, publish exclusion, disable behavior, supported mutations, and future extension points. ## Notable semantics - Only the root project owns `.npm-extension`. - Workspace package manifests are not extension targets; non-root workspace `.npm-extension` files warn and are ignored. - Dependency package `.npm-extension` files are ignored. - The only extension point in this RFC is top-level `transformManifest(pkg, context)`. - The extension point runs synchronously before dependency and peer edges are read. - Supported output changes are limited to `dependencies`, `optionalDependencies`, `peerDependencies`, and `peerDependenciesMeta`. - Unlike `packageExtensions`, the imperative form can delete supported dependency entries and replace existing normal dependency ranges. - Unsupported field changes such as `scripts`, `bin`, `exports`, `types`, and `bundleDependencies` are rejected. - `transformManifest` runs for non-root, non-workspace dependency manifests from registry, git, remote tarball, local file, local directory, and symlinked dependency sources. - npm records an extension entry-file hash and minimal `npmExtensionApplied` provenance in `package-lock.json`. - `npm ci` verifies matching extension state without importing or executing `.npm-extension`. - Changed extension file bytes make `npm install` re-run `transformManifest` across candidate manifests rather than relying on selector-based selective re-resolution. - Extension-created, extension-changed, and extension-removed dependency metadata is visible through lockfile provenance and npm inspection output. - `.npm-extension.mjs` and `.npm-extension.cjs` are excluded from `npm pack` and `npm publish`, even when listed in `files`. - `ignore-extension=true` disables extension execution, and `ignore-scripts=true` implies `ignore-extension=true` for commands that would otherwise execute it. ## Relationship to `packageExtensions` This RFC is not trying to replace `packageExtensions`. The declarative feature should remain the first choice for small, reviewable manifest repairs. The imperative extension file is for cases where the declarative shape becomes repetitive, cannot express the needed local policy, or cannot live in a publishable package manifest. The RFC keeps the two features ordered and auditable: `transformManifest` runs before `packageExtensions`, then npm reads dependency and peer edges from the resulting effective manifest. That lets imperative repairs inspect the upstream manifest before declarative repairs are applied, while preserving the accepted `packageExtensions` validation and provenance model. ## References Follow up of #889 --- > **Disclosure**: [Codex](https://chatgpt.com/codex/) and [Claude Code](https://claude.com/claude-code) were used to draft the initial version of this RFC and to iterate on it during review.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements package manifest extensions per RFC #889: a root-only
packageExtensionsfield inpackage.jsonthat applies declarative repairs to third-party dependency manifests before Arborist finalizes the ideal tree. It lets a project add missingdependencies/optionalDependencies, add or correctpeerDependencies, and mark peers optional viapeerDependenciesMeta, without forking and republishing a package.{ "packageExtensions": { "broken-package@1": { "dependencies": { "missing-runtime-dep": "^2.0.0" } }, "typescript-plugin@4.3.0": { "peerDependencies": { "typescript": ">=5" }, "peerDependenciesMeta": { "typescript": { "optional": true } } } } }Why
install-strategy=linkedgives installs strong package boundaries, which is also what makes adoption hard: a package only sees what it actually declared, so one that worked under a hoisted layout because a dependency happened to be hoisted above it can fail. A root-level dependency masks this under hoisting but does not make the package available inside the isolated boundary of the importer — the repair has to be attached to the broken package's manifest before its edges are resolved. This is the pre-resolution complement tooverrides(which needs an existing edge to retarget) and to native dependency patching #9439 (which edits package contents after resolution).The field
Each key is a package selector: a name with an optional semver range (
foo,foo@1,@scope/foo@^2.3.0). Selectors match a candidate's own manifestname/version(the underlying name for aliases) and reject dist-tag, git, file, URL, andnpm:specs. At most one selector may match a candidate. Honored only in the rootpackage.json(the workspace root); the field in dependencies and non-root workspaces, and selectors matching a workspace member, are ignored with a warning — matching the root-authority model ofoverrides.Merge semantics
Only the four resolution-affecting fields may be extended.
dependencies/optionalDependenciesadd a missing name only; providing a name already declared in either field is an error (useoverridesto change a version), which also forbids moving a name between the two.peerDependenciesshallow-merges by name, replacing an existing range.peerDependenciesMetamerges by name then key (e.g. addoptional: true); every meta entry must have a correspondingpeerDependenciesentry.null/false/"-") is not supported.The extension applies to a per-tree manifest copy: the shared pacote/cache manifest is never mutated, the installed
node_modules/<pkg>/package.jsonis not rewritten, andbundleDependenciesis unchanged.overridesstill controls the final resolution target of an extension-created edge.Lockfile
The root entry stores a canonical
packageExtensionsHash, and each affected entry stores minimal provenance (packageExtensionsApplied); effective dependency metadata is recorded as usual. Extension state forceslockfileVersion: 4so older npm clients abort rather than silently dropping the repaired graph.npm installre-resolves affected packages when the rule set changes;npm civalidates the hash, selector conflicts, and stale provenance before trusting the locked metadata.Visibility
npm explainappends(added by packageExtensions["foo@1"].dependencies.bar)to the edge;npm lsannotates the node andnpm ls --jsonincludespackageExtensionsApplied. Publishing a non-private package containing the field warns that it does not affect consumers.Notes
lockfileVersion: 4is shared with native dependency patching (#9439) as a common "older npm must not silently drop this" tripwire; both bump only when their own state is present. Whichever lands second should reuse the samemaxLockfileVersion/bump constants rather than introduce a competing version.References
Implements npm/rfcs#889